<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>2025新年快乐！万事如意！</title>
  <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
  <meta name="mobile-web-app-capable" content="yes">
  <meta name="apple-mobile-web-app-capable" content="yes">
  <meta name="theme-color" content="#000000">
  <link rel="shortcut icon" type="image/png"
    href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
  <link rel="icon" type="image/png" href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
  <link rel="apple-touch-icon-precomposed"
    href="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
  <meta name="msapplication-TileColor" content="#000000">
  <meta name="msapplication-TileImage"
    content="https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/firework-burst-icon.png">
  <link href="https://fonts.googleapis.com/css?family=Russo+One" rel="stylesheet">
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
  <link rel="stylesheet" href="./style.css">
  <style>
    * {
      position: relative;
      box-sizing: border-box;
    }

    html,
    body {
      height: 100%;
    }

    html {
      background-color: #000;
    }

    body {
      overflow: hidden;
      color: rgba(255, 255, 255, 0.5);
      font-family: "Russo One", arial, sans-serif;
      line-height: 1.25;
      letter-spacing: 0.06em;
    }

    .hide {
      opacity: 0;
      visibility: hidden;
    }

    .remove {
      display: none;
    }

    .blur {
      filter: blur(12px);
    }

    .container {
      height: 100%;
      display: flex;
      justify-content: center;
      align-items: center;
    }

    #loading-init {
      width: 100%;
      align-self: center;
      text-align: center;
      font-size: 2em;
    }

    #stage-container {
      overflow: hidden;
      box-sizing: initial;
      border: 1px solid #222;
      margin: -1px;
    }

    #canvas-container {
      width: 100%;
      height: 100%;
      transition: filter 0.3s;

    }

    #canvas-container canvas {
      position: absolute;
      mix-blend-mode: lighten;
    }

    #controls {
      position: absolute;
      top: 0;
      width: 100%;
      padding-bottom: 50px;
      display: flex;
      justify-content: space-between;
      transition: opacity 0.3s, visibility 0.3s;
    }

    @media (min-width: 800px) {
      #controls {
        visibility: visible;
      }

      #controls.hide:hover {
        opacity: 1;
      }
    }

    #menu {
      display: flex;
      flex-direction: column;
      justify-content: center;
      align-items: center;
      position: absolute;
      top: 0;
      bottom: 0;
      width: 100%;
      background-color: rgba(0, 0, 0, 0.42);
      transition: opacity 0.3s, visibility 0.3s;
    }

    #menu__header {
      padding: 20px 0 44px;
      font-size: 2em;
      text-transform: uppercase;
    }

    #menu form {
      width: 240px;
      padding: 0 20px;
      overflow: auto;
    }

    #menu .form-option {
      margin: 20px 0;
    }

    #menu .form-option label {
      text-transform: uppercase;
    }

    #menu .form-option--select label {
      display: block;
      margin-bottom: 6px;
    }

    #menu .form-option--select select {
      display: block;
      width: 100%;
      height: 30px;
      font-size: 1rem;
      font-family: "Russo One", arial, sans-serif;
      color: rgba(255, 255, 255, 0.5);
      letter-spacing: 0.06em;
      background-color: transparent;
      border: 1px solid rgba(255, 255, 255, 0.5);
    }

    #menu .form-option--select select option {
      background-color: black;
    }

    #menu .form-option--checkbox label {
      display: flex;
      align-items: center;
      transition: opacity 0.3s;
      -webkit-user-select: none;
      -moz-user-select: none;
      -ms-user-select: none;
      user-select: none;
    }

    #menu .form-option--checkbox input {
      display: block;
      width: 20px;
      height: 20px;
      margin-right: 8px;
      opacity: 0.5;
    }

    @media (max-width: 800px) {

      #menu .form-option select,
      #menu .form-option input {
        outline: none;
      }
    }

    #close-menu-btn {
      position: absolute;
      top: 0;
      right: 0;
    }

    .btn {
      opacity: 0.16;
      width: 44px;
      height: 44px;
      display: flex;
      -webkit-user-select: none;
      -moz-user-select: none;
      -ms-user-select: none;
      user-select: none;
      cursor: default;
      transition: opacity 0.3s;
    }

    .btn--bright {
      opacity: 0.5;
    }

    @media (min-width: 800px) {
      .btn:hover {
        opacity: 0.32;
      }

      .btn--bright:hover {
        opacity: 0.75;
      }
    }

    .btn svg {
      display: block;
      margin: auto;
    }

    #countdown {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 10rem;
      font-family: "Russo One", sans-serif;
      background: linear-gradient(45deg, #ff0043, #ffe600, #14fc56, #1e7fff, #e60aff);
      background-clip: text;
      -webkit-background-clip: text;
      color: transparent;
      text-shadow: 0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px rgba(255, 255, 255, 0.6);
      display: flex;
      align-items: center;
      justify-content: center;
      opacity: 1;
      transition: opacity 0.5s ease-out, transform 0.5s ease-out;
      animation: scalePulse 1s infinite ease-out;
    }

    @keyframes scalePulse {
      0% {
        transform: translate(-50%, -50%) scale(1);
      }

      50% {
        transform: translate(-50%, -50%) scale(1.3);
      }

      100% {
        transform: translate(-50%, -50%) scale(1);
      }
    }

    #countdown.hidden {
      opacity: 0;
      transform: translate(-50%, -50%) scale(0.5);
    }

    #countdown.final {
      font-size: 8rem;
      animation: glow 2s infinite ease-in-out;
    }

    @keyframes glow {

      0%,
      100% {
        text-shadow: 0 0 20px #ff0043, 0 0 40px #ff0043, 0 0 60px #ffe600, 0 0 80px #14fc56;
      }

      50% {
        text-shadow: 0 0 20px #1e7fff, 0 0 40px #e60aff, 0 0 60px #ff0043, 0 0 80px #ffe600;
      }
    }
  </style>
</head>

<body>
  <div style="height: 0; width: 0; position: absolute; visibility: hidden;">
    <svg xmlns="http://www.w3.org/2000/svg">
      <symbol id="icon-play" viewBox="0 0 24 24">
        <path d="M8 5v14l11-7z" />
      </symbol>
      <symbol id="icon-pause" viewBox="0 0 24 24">
        <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
      </symbol>
      <symbol id="icon-close" viewBox="0 0 24 24">
        <path
          d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
      </symbol>
      <symbol id="icon-settings" viewBox="0 0 24 24">
        <path
          d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z" />
      </symbol>
      <symbol id="icon-shutter-fast" viewBox="0 0 24 24">
        <path
          d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z" />
      </symbol>
      <symbol id="icon-shutter-slow" viewBox="0 0 24 24">
        <path
          d="M1 5h2v14H1zm4 0h2v14H5zm17 0H10c-.55 0-1 .45-1 1v12c0 .55.45 1 1 1h12c.55 0 1-.45 1-1V6c0-.55-.45-1-1-1zM11 17l2.5-3.15L15.29 16l2.5-3.22L21 17H11z" />
      </symbol>
    </svg>
  </div>


  <div class="container">

    <div id="loading-init">Loading...</div>
    <div id="stage-container" class="remove">
      <div id="canvas-container">
        <canvas id="trails-canvas"></canvas>
        <canvas id="main-canvas"></canvas>
      </div>
      <div id="countdown">3</div>

      <div id="menu" class="hide">
        <div id="close-menu-btn" class="btn btn--bright">
          <svg fill="white" width="24" height="24">
            <use href="#icon-close"></use>
          </svg>
        </div>
        <div id="menu__header">Settings</div>
        <form>
          <div class="form-option form-option--select">
            <label>Shell Type</label>
            <select id="shell-type"></select>
          </div>
          <div class="form-option form-option--select">
            <label>Shell Size</label>
            <select id="shell-size"></select>
          </div>
          <div class="form-option form-option--checkbox">
            <label id="auto-launch-label"><input id="auto-launch" type="checkbox" /><span>Auto Fire</span></label>
          </div>
          <div class="form-option form-option--checkbox">
            <label id="finale-mode-label"><input id="finale-mode" type="checkbox" /><span>Finale Mode</span></label>
          </div>
          <div class="form-option form-option--checkbox">
            <label id="hide-controls-label"><input id="hide-controls" type="checkbox" /><span>Hide
                Controls</span></label>
          </div>
        </form>
      </div>
    </div>
  </div>
  <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/fscreen%401.0.1.js'></script>
  <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/Stage%400.1.4.js'></script>
  <script src='https://s3-us-west-2.amazonaws.com/s.cdpn.io/329180/MyMath.js'></script>
  <script>
    'use strict';
    console.clear();
    // 显示倒计时
    let countdownNumber = 3;
    const countdownElement = document.getElementById('countdown');
    const countdownInterval = setInterval(() => {
      countdownElement.textContent = countdownNumber;



      countdownNumber--;

      if (countdownNumber < 0) {
        clearInterval(countdownInterval);

        // 设置“新年快乐”的文字和样式
        countdownElement.textContent = "新年快乐！";
        countdownElement.classList.remove('hidden');

      }
    }, 1000);

    function startFireworks() {
      store.setState({ config: { ...store.state.config, autoLaunch: true, finale: true } });

      // 增加整体速度倍数
      simSpeed = 3; // 默认速度基础上加快
    }

    const IS_MOBILE = window.innerWidth <= 640;
    const IS_DESKTOP = window.innerWidth > 800;
    const IS_HEADER = IS_DESKTOP && window.innerHeight < 300;
    // 8K - can restrict this if needed
    const MAX_WIDTH = 7680;
    const MAX_HEIGHT = 4320;
    const GRAVITY = 0.9; // Acceleration in px/s
    let simSpeed = 1;

    const COLOR = {
      Red: '#ff0043',
      Green: '#14fc56',
      Blue: '#1e7fff',
      Purple: '#e60aff',
      Gold: '#ffae00',
      White: '#ffffff'
    };

    // Special invisible color (not rendered, and therefore not in COLOR map)
    const INVISIBLE = '_INVISIBLE_';


    // Interactive state management
    const store = {
      _listeners: new Set(),
      _dispatch() {
        this._listeners.forEach(listener => listener(this.state))
      },
      state: {
        paused: false,
        longExposure: false,
        menuOpen: false,
        config: {
          shell: 'Random',
          size: IS_DESKTOP && !IS_HEADER ? '3' : '1',
          autoLaunch: true,
          finale: false,
          hideControls: IS_HEADER
        }
      },
      setState(nextState) {
        this.state = Object.assign({}, this.state, nextState);
        this._dispatch();
        this.persist();
      },
      subscribe(listener) {
        this._listeners.add(listener);
        return () => this._listeners.remove(listener);
      },
      // Load / persist select state to localStorage
      load() {
        if (localStorage.getItem('schemaVersion') === '1') {
          this.state.config.size = JSON.parse(localStorage.getItem('configSize'));
          this.state.config.hideControls = JSON.parse(localStorage.getItem('hideControls'));
        }
      },
      persist() {
        localStorage.setItem('schemaVersion', '1');
        localStorage.setItem('configSize', JSON.stringify(this.state.config.size));
        localStorage.setItem('hideControls', JSON.stringify(this.state.config.hideControls));
      }
    };

    if (!IS_HEADER) {
      store.load();
    }

    // Actions
    // ---------

    function togglePause(toggle) {
      if (typeof toggle === 'boolean') {
        store.setState({ paused: toggle });
      } else {
        store.setState({ paused: !store.state.paused });
      }
    }

    function toggleLongExposure(toggle) {
      if (typeof toggle === 'boolean') {
        store.setState({ longExposure: toggle });
      } else {
        store.setState({ longExposure: !store.state.longExposure });
      }
    }

    function toggleMenu(toggle) {
      if (typeof toggle === 'boolean') {
        store.setState({ menuOpen: toggle });
      } else {
        store.setState({ menuOpen: !store.state.menuOpen });
      }
    }

    function updateConfig(nextConfig) {
      nextConfig = nextConfig || getConfigFromDOM();
      store.setState({
        config: Object.assign({}, store.state.config, nextConfig)
      });
    }

    // Selectors
    // -----------

    const canInteract = () => !store.state.paused && !store.state.menuOpen;
    const shellNameSelector = () => store.state.config.shell;
    // Converts shell size to number.
    const shellSizeSelector = () => +store.state.config.size;
    const finaleSelector = () => store.state.config.finale;


    // Render app UI / keep in sync with state
    const appNodes = {
      stageContainer: '#stage-container',
      canvasContainer: '#canvas-container',
      controls: '#controls',
      menu: '#menu',
      pauseBtn: '#pause-btn',
      pauseBtnSVG: '#pause-btn use',
      shutterBtn: '#shutter-btn',
      shutterBtnSVG: '#shutter-btn use',
      shellType: '#shell-type',
      shellSize: '#shell-size',
      autoLaunch: '#auto-launch',
      autoLaunchLabel: '#auto-launch-label',
      finaleMode: '#finale-mode',
      finaleModeLabel: '#finale-mode-label',
      hideControls: '#hide-controls',
      hideControlsLabel: '#hide-controls-label'
    };

    // Convert appNodes selectors to dom nodes
    Object.keys(appNodes).forEach(key => {
      appNodes[key] = document.querySelector(appNodes[key]);
    });

    // Remove loading state
    document.getElementById('loading-init').remove();
    appNodes.stageContainer.classList.remove('remove');

    // First render is called in init()
    function renderApp(state) {
      appNodes.pauseBtnSVG.setAttribute('href', `#icon-${state.paused ? 'play' : 'pause'}`);
      appNodes.shutterBtnSVG.setAttribute('href', `#icon-shutter-${state.longExposure ? 'fast' : 'slow'}`);
      appNodes.controls.classList.toggle('hide', state.menuOpen || state.config.hideControls);
      appNodes.canvasContainer.classList.toggle('blur', state.menuOpen);
      appNodes.menu.classList.toggle('hide', !state.menuOpen);
      appNodes.finaleModeLabel.style.opacity = state.config.autoLaunch ? 1 : 0.32;
      appNodes.shellType.value = state.config.shell;
      appNodes.shellSize.value = state.config.size;
      appNodes.autoLaunch.checked = state.config.autoLaunch;
      appNodes.finaleMode.checked = state.config.finale;
      appNodes.hideControls.checked = state.config.hideControls;
    }

    store.subscribe(renderApp);


    function getConfigFromDOM() {
      return {
        shell: appNodes.shellType.value,
        size: appNodes.shellSize.value,
        autoLaunch: appNodes.autoLaunch.checked,
        finale: appNodes.finaleMode.checked,
        hideControls: appNodes.hideControls.checked
      };
    };

    const updateConfigNoEvent = () => updateConfig();
    appNodes.shellType.addEventListener('input', updateConfigNoEvent);
    appNodes.shellSize.addEventListener('input', updateConfigNoEvent);
    appNodes.autoLaunchLabel.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.finaleModeLabel.addEventListener('click', () => setTimeout(updateConfig, 0));
    appNodes.hideControlsLabel.addEventListener('click', () => setTimeout(updateConfig, 0));


    // Constant derivations
    const COLOR_NAMES = Object.keys(COLOR);
    const COLOR_CODES = COLOR_NAMES.map(colorName => COLOR[colorName]);
    // Invisible stars need an indentifier, even through they won't be rendered - physics still apply.
    const COLOR_CODES_W_INVIS = [...COLOR_CODES, INVISIBLE];
    // Tuples is a map keys by color codes (hex) with values of { r, g, b } tuples (still just objects).
    const COLOR_TUPLES = {};
    COLOR_CODES.forEach(hex => {
      COLOR_TUPLES[hex] = {
        r: parseInt(hex.substr(1, 2), 16),
        g: parseInt(hex.substr(3, 2), 16),
        b: parseInt(hex.substr(5, 2), 16),
      };
    });

    // Get a random color.
    function randomColorSimple() {
      return COLOR_CODES[Math.random() * COLOR_CODES.length | 0];
    }

    // Get a random color, with some customization options available.
    let lastColor;
    function randomColor(options) {
      const notSame = options && options.notSame;
      const notColor = options && options.notColor;
      const limitWhite = options && options.limitWhite;
      let color = randomColorSimple();
      // limit the amount of white chosen randomly
      if (limitWhite && color === COLOR.White && Math.random() < 0.6) {
        color = randomColorSimple();
      }
      if (notSame) {
        while (color === lastColor) {
          color = randomColorSimple();
        }
      }
      else if (notColor) {
        while (color === notColor) {
          color = randomColorSimple();
        }
      }
      lastColor = color;
      return color;
    }

    function whiteOrGold() {
      return Math.random() < 0.5 ? COLOR.Gold : COLOR.White;
    }

    const PI_2 = Math.PI * 2;
    const PI_HALF = Math.PI * 0.5;

    const trailsStage = new Stage('trails-canvas');
    const mainStage = new Stage('main-canvas');
    const stages = [
      trailsStage,
      mainStage
    ];

    // Fill trails canvas with black to start.
    trailsStage.ctx.fillStyle = '#000';
    trailsStage.ctx.fillRect(0, 0, trailsStage.width, trailsStage.height);


    // Fullscreen helpers, using Fscreen for prefixes
    function requestFullscreen() {
      if (fullscreenEnabled() && !isFullscreen()) {
        fscreen.requestFullscreen(document.documentElement);
      }
    }

    function fullscreenEnabled() {
      return fscreen.fullscreenEnabled;
    }

    function isFullscreen() {
      return !!fscreen.fullscreenElement;
    }


    // Shell helpers
    function makePistilColor(shellColor) {
      return (shellColor === COLOR.White || shellColor === COLOR.Gold) ? randomColor({ notColor: shellColor }) : whiteOrGold();
    }

    // Unique shell types
    //生成菊花状的烟花效果
    const crysanthemumShell = (size = 1) => {
      const glitter = Math.random() < 0.25;//是否产生闪光效果
      const singleColor = Math.random() < 0.68;//是否使用单一颜色
      //一个颜色数组，包含1到2个颜色值。如果singleColor为真，则该数组仅包含一个颜色；否则该数组将包含两个不同的颜色。颜色值通过调用randomColor函数随机生成
      const color = singleColor ? randomColor({ limitWhite: true }) : [randomColor(), randomColor({ notSame: true })];
      const pistil = singleColor && Math.random() < 0.42;//是否绘制花蕊
      const pistilColor = makePistilColor(color);//绘制花蕊,花蕊的颜色
      const streamers = !pistil && color !== COLOR.White && Math.random() < 0.42;//是否绘制流星效果
      return {
        size: 300 + size * 100,//烟花的大小
        starLife: 900 + size * 200,//星星效果的寿命
        starDensity: glitter ? 1.1 : 1.5,//星星效果的密度
        color,
        glitter: glitter ? 'light' : '',//闪光效果的类型
        glitterColor: whiteOrGold(),//绘制闪光效果
        pistil,
        pistilColor,
        streamers
      };
    };

    //生成棕榈树状的烟花效果
    const palmShell = (size = 1) => ({
      size: 250 + size * 75,
      starDensity: 0.6,
      starLife: 1800 + size * 200,
      glitter: 'heavy'
    });

    //用于生成环状的烟花效果
    const ringShell = (size = 1) => {
      const color = randomColor();
      const pistil = Math.random() < 0.75;
      return {
        ring: true,
        color,
        size: 300 + size * 100,
        starLife: 900 + size * 200,
        starCount: 2.2 * PI_2 * (size + 1),
        pistil,
        pistilColor: makePistilColor(color),
        glitter: !pistil ? 'light' : '',
        glitterColor: color === COLOR.Gold ? COLOR.Gold : COLOR.White
      };
    };

    //生成十字状的烟花效果
    const crossetteShell = (size = 1) => {
      const color = randomColor({ limitWhite: true });
      return {
        size: 300 + size * 100,
        starLife: 900 + size * 200,
        starLifeVariation: 0.22,
        color,
        crossette: true,
        pistil: Math.random() < 0.5,
        pistilColor: makePistilColor(color)
      };
    };

    //生成花朵状的烟花效果
    const floralShell = (size = 1) => ({
      size: 300 + size * 120,
      starDensity: 0.38,
      starLife: 500 + size * 50,
      starLifeVariation: 0.5,
      color: Math.random() < 0.65 ? 'random' : (Math.random() < 0.15 ? randomColor() : [randomColor(), randomColor({ notSame: true })]),
      floral: true
    });

    //生成落叶状的烟花效果
    const fallingLeavesShell = (size = 1) => ({
      color: INVISIBLE,
      size: 300 + size * 120,
      starDensity: 0.38,
      starLife: 500 + size * 50,
      starLifeVariation: 0.5,
      glitter: 'medium',
      glitterColor: COLOR.Gold,
      fallingLeaves: true
    });

    //生成柳树状烟花效果
    const willowShell = (size = 1) => ({
      size: 300 + size * 100,
      starDensity: 0.7,
      starLife: 3000 + size * 300,
      glitter: 'willow',
      glitterColor: COLOR.Gold,
      color: INVISIBLE
    });

    //生成爆裂声烟花（crackleShell）效果
    const crackleShell = (size = 1) => {
      // favor gold
      const color = Math.random() < 0.75 ? COLOR.Gold : randomColor();
      return {
        size: 380 + size * 75,
        starDensity: 1,
        starLife: 600 + size * 100,
        starLifeVariation: 0.32,
        glitter: 'light',
        glitterColor: COLOR.Gold,
        color,
        crackle: true,
        pistil: Math.random() < 0.65,
        pistilColor: makePistilColor(color)
      };
    };

    //马尾状烟花效果
    const horsetailShell = (size = 1) => {
      const color = randomColor();
      return {
        horsetail: true,
        color,
        size: 250 + size * 38,
        starDensity: 0.85 + size * 0.1,
        starLife: 2500 + size * 300,
        glitter: 'medium',
        glitterColor: Math.random() < 0.5 ? whiteOrGold() : color
      };
    };


    function randomShellName() {
      return Math.random() < 0.6 ? 'Crysanthemum' : shellNames[(Math.random() * (shellNames.length - 1) + 1) | 0];
    }

    function randomShell(size) {
      return shellTypes[randomShellName()](size);
    }

    function shellFromConfig(size) {
      return shellTypes[shellNameSelector()](size);
    }

    // Get a random shell, not including processing intensive varients
    // Note this is only random when "Random" shell is selected in config.
    // Also, this does not create the shell, only returns the factory function.
    const fastShellBlacklist = ['Falling Leaves', 'Floral', 'Willow'];
    function randomFastShell() {
      const isRandom = shellNameSelector() === 'Random';
      let shellName = isRandom ? randomShellName() : shellNameSelector();
      if (isRandom) {
        while (fastShellBlacklist.includes(shellName)) {
          shellName = randomShellName();
        }
      }
      return shellTypes[shellName];
    }


    const shellTypes = {
      'Random': randomShell,
      'Crackle': crackleShell,
      'Crossette': crossetteShell,
      'Crysanthemum': crysanthemumShell,
      'Falling Leaves': fallingLeavesShell,
      'Floral': floralShell,
      'Horse Tail': horsetailShell,
      'Palm': palmShell,
      'Ring': ringShell,
      'Willow': willowShell
    };

    const shellNames = Object.keys(shellTypes);




    function fitShellPositionInBoundsH(position) {
      const edge = 0.18;
      return (1 - edge * 2) * position + edge;
    }

    function fitShellPositionInBoundsV(position) {
      return position * 0.75;
    }

    function getRandomShellPositionH() {
      return fitShellPositionInBoundsH(Math.random());
    }

    function getRandomShellPositionV() {
      return fitShellPositionInBoundsV(Math.random());
    }

    function getRandomShellSize() {
      const baseSize = shellSizeSelector();
      const maxVariance = Math.min(2.5, baseSize);
      const variance = Math.random() * maxVariance;
      const size = baseSize - variance;
      const height = maxVariance === 0 ? Math.random() : 1 - (variance / maxVariance);
      const centerOffset = Math.random() * (1 - height * 0.65) * 0.5;
      const x = Math.random() < 0.5 ? 0.5 - centerOffset : 0.5 + centerOffset;
      return {
        size,
        x: fitShellPositionInBoundsH(x),
        height: fitShellPositionInBoundsV(height)
      };
    }


    // Launches a shell from a user pointer event, based on state.config
    function launchShellFromConfig(event) {
      const shell = new Shell(shellFromConfig(shellSizeSelector()));
      const w = mainStage.width;
      const h = mainStage.height;
      shell.launch(
        event ? event.x / w : getRandomShellPositionH(),
        event ? 1 - event.y / h : getRandomShellPositionV()
      );
    }


    // Sequences
    // -----------

    function seqRandomShell() {
      const size = getRandomShellSize();
      const shell = new Shell(shellFromConfig(size.size));
      shell.launch(size.x, size.height);

      let extraDelay = shell.starLife;
      if (shell.fallingLeaves) {
        extraDelay = 2000; // 减少延迟时间
      }
      return 300 + Math.random() * 200 + extraDelay; // 减少发射间隔
    }

    function seqTwoRandom() {
      const size1 = getRandomShellSize();
      const size2 = getRandomShellSize();
      const shell1 = new Shell(shellFromConfig(size1.size));
      const shell2 = new Shell(shellFromConfig(size2.size));
      const leftOffset = Math.random() * 0.2 - 0.1;
      const rightOffset = Math.random() * 0.2 - 0.1;
      shell1.launch(0.3 + leftOffset, size1.height);
      shell2.launch(0.7 + rightOffset, size2.height);
      let extraDelay = Math.max(shell1.starLife, shell2.starLife);
      if (shell1.fallingLeaves || shell2.fallingLeaves) {
        extraDelay = 4000;
      }
      return 900 + Math.random() * 600 + extraDelay;
    }

    function seqTriple() {
      const shellType = randomFastShell();
      const baseSize = shellSizeSelector();
      const smallSize = Math.max(0, baseSize - 1.25);
      const offset = Math.random() * 0.08 - 0.04;
      const shell1 = new Shell(shellType(baseSize));
      shell1.launch(0.5 + offset, 0.7);
      const leftDelay = 1000 + Math.random() * 400;
      const rightDelay = 1000 + Math.random() * 400;
      setTimeout(() => {
        const offset = Math.random() * 0.08 - 0.04;
        const shell2 = new Shell(shellType(smallSize));
        shell2.launch(0.2 + offset, 0.1);
      }, leftDelay);
      setTimeout(() => {
        const offset = Math.random() * 0.08 - 0.04;
        const shell3 = new Shell(shellType(smallSize));
        shell3.launch(0.8 + offset, 0.1);
      }, rightDelay);
      return 4000;
    }

    function seqSmallBarrage() {
      seqSmallBarrage.lastCalled = Date.now();
      const barrageCount = IS_DESKTOP ? 11 : 5;
      const shellSize = Math.max(0, shellSizeSelector() - 2);
      const useCrysanthemum = Math.random() < 0.7;
      // (cos(x*5π+0.5π)+1)/2 is a custom wave bounded by 0 and 1 used to set varying launch heights
      function launchShell(x) {
        const isRandom = shellNameSelector() === 'Random';
        let shellType = isRandom ? (useCrysanthemum ? crysanthemumShell : randomFastShell()) : shellTypes[shellNameSelector()];
        const shell = new Shell(shellType(shellSize));
        const height = (Math.cos(x * 5 * Math.PI + PI_HALF) + 1) / 2;
        shell.launch(x, height * 0.75);
      }
      let count = 0;
      let delay = 0;
      while (count < barrageCount) {
        if (count === 0) {
          launchShell(0.5)
          count += 1;
        }
        else {
          const offset = (count + 1) / barrageCount / 2;
          setTimeout(() => {
            launchShell(0.5 + offset);
            launchShell(0.5 - offset);
          }, delay);
          count += 2;
        }
        delay += 200;
      }
      return 3400 + barrageCount * 120;
    }
    seqSmallBarrage.cooldown = 15000;
    seqSmallBarrage.lastCalled = Date.now();


    const sequences = [
      seqRandomShell,
      seqTwoRandom,
      seqTriple,
      seqSmallBarrage
    ];


    let isFirstSeq = true;
    const finaleCount = 32;
    let currentFinaleCount = 0;
    function startSequence() {
      if (isFirstSeq) {
        isFirstSeq = false;
        const shell = new Shell(crysanthemumShell(shellSizeSelector()));
        shell.launch(0.5, 0.5);
        return 2400;
      }
      if (finaleSelector()) {
        seqRandomShell();
        if (currentFinaleCount < finaleCount) {
          currentFinaleCount++;
          return 170;
        }
        else {
          currentFinaleCount = 0;
          return 6000;
        }
      }
      const rand = Math.random();
      if (rand < 0.2 && Date.now() - seqSmallBarrage.lastCalled > seqSmallBarrage.cooldown) {
        return seqSmallBarrage();
      }
      if (rand < 0.6) {
        return seqRandomShell();
      }
      else if (rand < 0.8) {
        return seqTwoRandom();
      }
      else if (rand < 1) {
        return seqTriple();
      }
    }


    let activePointerCount = 0;
    let isUpdatingSpeed = false;

    function handlePointerStart(event) {
      activePointerCount++;
      const btnSize = 44;
      if (event.y < btnSize) {
        if (event.x < btnSize) {
          togglePause();
          return;
        }
        if (event.x > mainStage.width / 2 - btnSize / 2 && event.x < mainStage.width / 2 + btnSize / 2) {
          toggleLongExposure();
          return;
        }
        if (event.x > mainStage.width - btnSize) {
          toggleMenu();
          return;
        }
      }
      if (!canInteract()) return;
      if (updateSpeedFromEvent(event)) {
        isUpdatingSpeed = true;
      }
      else if (event.onCanvas) {
        launchShellFromConfig(event);
      }
    }

    function handlePointerEnd(event) {
      activePointerCount--;
      isUpdatingSpeed = false;
    }

    function handlePointerMove(event) {
      if (!canInteract()) return;
      if (isUpdatingSpeed) {
        updateSpeedFromEvent(event);
      }
    }

    function handleKeydown(event) {
      // P
      if (event.keyCode === 80) {
        togglePause();
      }
      // O
      else if (event.keyCode === 79) {
        toggleMenu();
      }
      // Esc
      else if (event.keyCode === 27) {
        toggleMenu(false);
      }
    }

    mainStage.addEventListener('pointerstart', handlePointerStart);
    mainStage.addEventListener('pointerend', handlePointerEnd);
    mainStage.addEventListener('pointermove', handlePointerMove);
    window.addEventListener('keydown', handleKeydown);
    // Try to go fullscreen upon a touch
    window.addEventListener('touchend', (event) => !IS_DESKTOP && requestFullscreen());


    function handleResize() {
      const w = window.innerWidth;
      const h = window.innerHeight;
      // Try to adopt screen size, heeding maximum sizes specified
      const containerW = Math.min(w, MAX_WIDTH);
      // On small screens, use full device height
      const containerH = w <= 420 ? h : Math.min(h, MAX_HEIGHT);
      appNodes.stageContainer.style.width = containerW + 'px';
      appNodes.stageContainer.style.height = containerH + 'px';
      stages.forEach(stage => stage.resize(containerW, containerH));
    }

    // Compute initial dimensions
    handleResize();

    window.addEventListener('resize', handleResize);


    // Dynamic globals
    let speedBarOpacity = 0;
    let autoLaunchTime = 0;

    function updateSpeedFromEvent(event) {
      if (isUpdatingSpeed || event.y >= mainStage.height - 44) {
        // On phones it's hard to hit the edge pixels in order to set speed at 0 or 1, so some padding is provided to make that easier.
        const edge = 16;
        const newSpeed = (event.x - edge) / (mainStage.width - edge * 2);
        simSpeed = Math.min(Math.max(newSpeed, 0), 1);
        // show speed bar after an update
        speedBarOpacity = 1;
        // If we updated the speed, return true
        return true;
      }
      // Return false if the speed wasn't updated
      return false;
    }


    // Extracted function to keep `update()` optimized
    function updateGlobals(timeStep, lag) {
      // Always try to fade out speed bar
      if (!isUpdatingSpeed) {
        speedBarOpacity -= lag / 30; // half a second
        if (speedBarOpacity < 0) {
          speedBarOpacity = 0;
        }
      }
      // auto launch shells
      if (store.state.config.autoLaunch) {
        autoLaunchTime -= timeStep;
        if (autoLaunchTime <= 0) {
          autoLaunchTime = startSequence();
        }
      }
    }


    function update(frameTime, lag) {
      if (!canInteract()) return;
      const { width, height } = mainStage;
      const timeStep = frameTime * simSpeed;
      const speed = simSpeed * lag;
      updateGlobals(timeStep, lag);
      const starDrag = 1 - (1 - Star.airDrag) * speed;
      const starDragHeavy = 1 - (1 - Star.airDragHeavy) * speed;
      const sparkDrag = 1 - (1 - Spark.airDrag) * speed;
      const gAcc = timeStep / 1000 * GRAVITY;
      COLOR_CODES_W_INVIS.forEach(color => {
        // Stars
        Star.active[color].forEach((star, i, stars) => {
          star.life -= timeStep;
          if (star.life <= 0) {
            stars.splice(i, 1);
            Star.returnInstance(star);
          } else {
            star.prevX = star.x;
            star.prevY = star.y;
            star.x += star.speedX * speed;
            star.y += star.speedY * speed;
            // Apply air drag if star isn't "heavy". The heavy property is used for the shell comets.
            if (!star.heavy) {
              star.speedX *= starDrag;
              star.speedY *= starDrag;
            }
            else {
              star.speedX *= starDragHeavy;
              star.speedY *= starDragHeavy;
            }
            star.speedY += gAcc;
            if (star.spinRadius) {
              star.spinAngle += star.spinSpeed * speed;
              star.x += Math.sin(star.spinAngle) * star.spinRadius * speed;
              star.y += Math.cos(star.spinAngle) * star.spinRadius * speed;
            }
            if (star.sparkFreq) {
              star.sparkTimer -= timeStep;
              while (star.sparkTimer < 0) {
                star.sparkTimer += star.sparkFreq;
                Spark.add(
                  star.x,
                  star.y,
                  star.sparkColor,
                  Math.random() * PI_2,
                  Math.random() * star.sparkSpeed,
                  star.sparkLife * 0.8 + Math.random() * star.sparkLifeVariation * star.sparkLife
                );
              }
            }
          }
        });
        // Sparks
        Spark.active[color].forEach((spark, i, sparks) => {
          spark.life -= timeStep;
          if (spark.life <= 0) {
            sparks.splice(i, 1);
            Spark.returnInstance(spark);
          } else {
            spark.prevX = spark.x;
            spark.prevY = spark.y;
            spark.x += spark.speedX * speed;
            spark.y += spark.speedY * speed;
            spark.speedX *= sparkDrag;
            spark.speedY *= sparkDrag;
            spark.speedY += gAcc;
          }
        });
      });
      render(speed);
    }

    function render(speed) {
      const { dpr, width, height } = mainStage;
      const trailsCtx = trailsStage.ctx;
      const mainCtx = mainStage.ctx;
      colorSky(speed);
      trailsCtx.scale(dpr, dpr);
      mainCtx.scale(dpr, dpr);
      trailsCtx.globalCompositeOperation = 'source-over';
      trailsCtx.fillStyle = `rgba(0, 0, 0, ${store.state.longExposure ? 0.0025 : 0.1 * speed})`;
      trailsCtx.fillRect(0, 0, width, height);
      // Remaining drawing on trails canvas will use 'lighten' blend mode
      trailsCtx.globalCompositeOperation = 'lighten';
      mainCtx.clearRect(0, 0, width, height);
      // Draw queued burst flashes
      while (BurstFlash.active.length) {
        const bf = BurstFlash.active.pop();
        const burstGradient = trailsCtx.createRadialGradient(bf.x, bf.y, 0, bf.x, bf.y, bf.radius);
        burstGradient.addColorStop(0.05, 'white');
        burstGradient.addColorStop(0.25, 'rgba(255, 160, 20, 0.2)');
        burstGradient.addColorStop(1, 'rgba(255, 160, 20, 0)');
        trailsCtx.fillStyle = burstGradient;
        trailsCtx.fillRect(bf.x - bf.radius, bf.y - bf.radius, bf.radius * 2, bf.radius * 2);
        BurstFlash.returnInstance(bf);
      }
      // Draw stars
      trailsCtx.lineWidth = Star.drawWidth;
      trailsCtx.lineCap = 'round';
      mainCtx.strokeStyle = '#fff';
      mainCtx.lineWidth = 1;
      mainCtx.beginPath();
      COLOR_CODES.forEach(color => {
        const stars = Star.active[color];
        trailsCtx.strokeStyle = color;
        trailsCtx.beginPath();
        stars.forEach(star => {
          trailsCtx.moveTo(star.x, star.y);
          trailsCtx.lineTo(star.prevX, star.prevY);
          mainCtx.moveTo(star.x, star.y);
          mainCtx.lineTo(star.x - star.speedX * 1.6, star.y - star.speedY * 1.6);
        });
        trailsCtx.stroke();
      });
      mainCtx.stroke();

      // Draw sparks
      trailsCtx.lineWidth = Spark.drawWidth;
      trailsCtx.lineCap = 'butt';
      COLOR_CODES.forEach(color => {
        const sparks = Spark.active[color];
        trailsCtx.strokeStyle = color;
        trailsCtx.beginPath();
        sparks.forEach(spark => {
          trailsCtx.moveTo(spark.x, spark.y);
          trailsCtx.lineTo(spark.prevX, spark.prevY);
        });
        trailsCtx.stroke();
      });
      // Render speed bar if visible
      if (speedBarOpacity) {
        const speedBarHeight = 6;
        mainCtx.globalAlpha = speedBarOpacity;
        mainCtx.fillStyle = COLOR.Blue;
        mainCtx.fillRect(0, height - speedBarHeight, width * simSpeed, speedBarHeight);
        mainCtx.globalAlpha = 1;
      }
      trailsCtx.resetTransform();
      mainCtx.resetTransform();
    }


    // Draw colored overlay based on combined brightness of stars (light up the sky!)
    // Note: this is applied to the canvas container's background-color, so it's behind the particles
    const currentSkyColor = { r: 0, g: 0, b: 0 };
    const targetSkyColor = { r: 0, g: 0, b: 0 };
    function colorSky(speed) {
      // The maximum r, g, or b value that will be used (255 would represent no maximum)
      const maxSkySaturation = 30;
      // How many stars are required in total to reach maximum sky brightness
      const maxStarCount = 500;
      let totalStarCount = 0;
      // Initialize sky as black
      targetSkyColor.r = 0;
      targetSkyColor.g = 0;
      targetSkyColor.b = 0;
      // Add each known color to sky, multiplied by particle count of that color. This will put RGB values wildly out of bounds, but we'll scale them back later.
      // Also add up total star count.
      COLOR_CODES.forEach(color => {
        const tuple = COLOR_TUPLES[color];
        const count = Star.active[color].length;
        totalStarCount += count;
        targetSkyColor.r += tuple.r * count;
        targetSkyColor.g += tuple.g * count;
        targetSkyColor.b += tuple.b * count;
      });
      // Clamp intensity at 1.0, and map to a custom non-linear curve. This allows few stars to perceivably light up the sky, while more stars continue to increase the brightness but at a lesser rate. This is more inline with humans' non-linear brightness perception.
      const intensity = Math.pow(Math.min(1, totalStarCount / maxStarCount), 0.3);
      // Figure out which color component has the highest value, so we can scale them without affecting the ratios.
      // Prevent 0 from being used, so we don't divide by zero in the next step.
      const maxColorComponent = Math.max(1, targetSkyColor.r, targetSkyColor.g, targetSkyColor.b);
      // Scale all color components to a max of `maxSkySaturation`, and apply intensity.
      targetSkyColor.r = targetSkyColor.r / maxColorComponent * maxSkySaturation * intensity;
      targetSkyColor.g = targetSkyColor.g / maxColorComponent * maxSkySaturation * intensity;
      targetSkyColor.b = targetSkyColor.b / maxColorComponent * maxSkySaturation * intensity;
      // Animate changes to color to smooth out transitions.
      const colorChange = 10;
      currentSkyColor.r += (targetSkyColor.r - currentSkyColor.r) / colorChange * speed;
      currentSkyColor.g += (targetSkyColor.g - currentSkyColor.g) / colorChange * speed;
      currentSkyColor.b += (targetSkyColor.b - currentSkyColor.b) / colorChange * speed;
      appNodes.canvasContainer.style.backgroundColor = `rgb(${currentSkyColor.r | 0}, ${currentSkyColor.g | 0}, ${currentSkyColor.b | 0})`;
    }

    mainStage.addEventListener('ticker', update);


    // Helper used to semi-randomly spread particles over an arc
    // Values are flexible - `start` and `arcLength` can be negative, and `randomness` is simply a multiplier for random addition.
    function createParticleArc(start, arcLength, count, randomness, particleFactory) {
      const angleDelta = arcLength / count;
      // Sometimes there is an extra particle at the end, too close to the start. Subtracting half the angleDelta ensures that is skipped.
      // Would be nice to fix this a better way.
      const end = start + arcLength - (angleDelta * 0.5);
      if (end > start) {
        // Optimization: `angle=angle+angleDelta` vs. angle+=angleDelta
        // V8 deoptimises with let compound assignment
        for (let angle = start; angle < end; angle = angle + angleDelta) {
          particleFactory(angle + Math.random() * angleDelta * randomness);
        }
      }
      else {
        for (let angle = start; angle > end; angle = angle + angleDelta) {
          particleFactory(angle + Math.random() * angleDelta * randomness);
        }
      }
    }


    // Various star effects.
    // These are designed to be attached to a star's `onDeath` event.

    // Crossette breaks star into four same-color pieces which branch in a cross-like shape.
    function crossetteEffect(star) {
      const startAngle = Math.random() * PI_HALF;
      createParticleArc(startAngle, PI_2, 4, 0.5, (angle) => {
        Star.add(
          star.x,
          star.y,
          star.color,
          angle,
          Math.random() * 0.6 + 0.75,
          600
        );
      });
    }

    // Flower is like a mini shell
    function floralEffect(star) {
      const startAngle = Math.random() * PI_HALF;
      createParticleArc(startAngle, PI_2, 24, 1, (angle) => {
        Star.add(
          star.x,
          star.y,
          star.color,
          angle,
          // apply near cubic falloff to speed (places more particles towards outside)
          Math.pow(Math.random(), 0.45) * 2.4,
          1000 + Math.random() * 300,
          star.speedX,
          star.speedY
        );
      });
      // Queue burst flash render
      BurstFlash.add(star.x, star.y, 24);
    }

    // Floral burst with willow stars
    function fallingLeavesEffect(star) {
      const startAngle = Math.random() * PI_HALF;
      createParticleArc(startAngle, PI_2, 12, 1, (angle) => {
        const newStar = Star.add(
          star.x,
          star.y,
          INVISIBLE,
          angle,
          // apply near cubic falloff to speed (places more particles towards outside)
          Math.pow(Math.random(), 0.45) * 2.4,
          2400 + Math.random() * 600,
          star.speedX,
          star.speedY
        );
        newStar.sparkColor = COLOR.Gold;
        newStar.sparkFreq = 72;
        newStar.sparkSpeed = 0.28;
        newStar.sparkLife = 750;
        newStar.sparkLifeVariation = 3.2;
      });
      // Queue burst flash render
      BurstFlash.add(star.x, star.y, 24);
    }

    // Crackle pops into a small cloud of golden sparks.
    function crackleEffect(star) {
      createParticleArc(0, PI_2, 10, 1.8, (angle) => {
        Spark.add(
          star.x,
          star.y,
          COLOR.Gold,
          angle,
          // apply near cubic falloff to speed (places more particles towards outside)
          Math.pow(Math.random(), 0.45) * 2.4,
          300 + Math.random() * 200
        );
      });
    }



    /**
     * Shell can be constructed with options:
     *
     * size:      Size of the burst.
     * starCount: Number of stars to create. This is optional, and will be set to a reasonable quantity for size if omitted.
     * starLife:
     * starLifeVariation:
     * color:
     * glitterColor:
     * glitter: One of: 'light', 'medium', 'heavy', 'streamer', 'willow'
     * pistil:
     * pistilColor:
     * streamers:
     * crossette:
     * floral:
     * crackle:
     */

    class Shell {
      constructor(options) {
        Object.assign(this, options);
        this.starLifeVariation = options.starLifeVariation || 0.125;
        this.color = options.color || randomColor();
        this.glitterColor = options.glitterColor || this.color;
        // Set default starCount if needed, will be based on shell size and scale exponentially, like a sphere's surface area.
        if (!this.starCount) {
          const density = options.starDensity || 1;
          const scaledSize = this.size / 50 * density;
          this.starCount = scaledSize * scaledSize;
        }
      }
      launch(position, launchHeight) {
        const { width, height } = mainStage;
        // Distance from sides of screen to keep shells.
        const hpad = 60;
        // Distance from top of screen to keep shell bursts.
        const vpad = 50;
        // Minimum burst height, as a percentage of stage height
        const minHeightPercent = 0.45;
        // Minimum burst height in px
        const minHeight = height - height * minHeightPercent;
        const launchX = position * (width - hpad * 2) + hpad;
        const launchY = height;
        const burstY = minHeight - (launchHeight * (minHeight - vpad));
        const launchDistance = launchY - burstY;
        // Using a custom power curve to approximate Vi needed to reach launchDistance under gravity and air drag.
        // Magic numbers came from testing.
        const launchVelocity = Math.pow(launchDistance * 0.04, 0.64);
        const comet = this.comet = Star.add(
          launchX,
          launchY,
          typeof this.color === 'string' && this.color !== 'random' ? this.color : COLOR.White,
          Math.PI,
          launchVelocity * (this.horsetail ? 1.2 : 1),
          // Hang time is derived linearly from Vi; exact number came from testing
          launchVelocity * (this.horsetail ? 100 : 400)
        );
        // making comet "heavy" limits air drag
        comet.heavy = true;
        // comet spark trail
        comet.spinRadius = 0.78;
        comet.sparkFreq = 16;
        if (this.glitter === 'willow' || this.fallingLeaves) {
          comet.sparkFreq = 10;
          comet.sparkSpeed = 0.5;
          comet.sparkLife = 500;
          comet.sparkLifeVariation = 3;
        }
        if (this.color === INVISIBLE) {
          comet.sparkColor = COLOR.Gold;
        }
        comet.onDeath = comet => this.burst(comet.x, comet.y);
        // comet.onDeath = () => this.burst(launchX, burstY);
      }
      burst(x, y) {
        // Set burst speed so overall burst grows to set size. This specific formula was derived from testing, and is affected by simulated air drag.
        const speed = this.size / 96;

        let color, onDeath, sparkFreq, sparkSpeed, sparkLife;
        let sparkLifeVariation = 0.25;
        if (this.crossette) onDeath = crossetteEffect;
        if (this.floral) onDeath = floralEffect;
        if (this.crackle) onDeath = crackleEffect;
        if (this.fallingLeaves) onDeath = fallingLeavesEffect;
        if (this.glitter === 'light') {
          sparkFreq = 200;
          sparkSpeed = 0.25;
          sparkLife = 600;
        }
        else if (this.glitter === 'medium') {
          sparkFreq = 100;
          sparkSpeed = 0.36;
          sparkLife = 1400;
        }
        else if (this.glitter === 'heavy') {
          sparkFreq = 42;
          sparkSpeed = 0.62;
          sparkLife = 2800;
        }
        else if (this.glitter === 'streamer') {
          sparkFreq = 20;
          sparkSpeed = 0.75;
          sparkLife = 800;
        }
        else if (this.glitter === 'willow') {
          sparkFreq = 72;
          sparkSpeed = 0.28;
          sparkLife = 1000;
          sparkLifeVariation = 3.4;
        }
        const starFactory = angle => {
          const star = Star.add(
            x,
            y,
            color || randomColor(),
            angle,
            // apply near cubic falloff to speed (places more particles towards outside)
            Math.pow(Math.random(), 0.45) * speed,
            // add minor variation to star life
            this.starLife + Math.random() * this.starLife * this.starLifeVariation,
            this.horsetail && this.comet && this.comet.speedX,
            this.horsetail && this.comet && this.comet.speedY
          );

          star.onDeath = onDeath;

          if (this.glitter) {
            star.sparkFreq = sparkFreq;
            star.sparkSpeed = sparkSpeed;
            star.sparkLife = sparkLife;
            star.sparkLifeVariation = sparkLifeVariation;
            star.sparkColor = this.glitterColor;
            star.sparkTimer = Math.random() * star.sparkFreq;
          }
        };
        if (typeof this.color === 'string') {
          if (this.color === 'random') {
            color = null; // falsey value creates random color in starFactory
          } else {
            color = this.color;
          }
          // Rings have positional randomness, but are rotated randomly
          if (this.ring) {
            const ringStartAngle = Math.random() * Math.PI;
            const ringSquash = Math.pow(Math.random(), 0.45) * 0.992 + 0.008;
            createParticleArc(0, PI_2, this.starCount, 0, angle => {
              // Create a ring, squashed horizontally
              const initSpeedX = Math.sin(angle) * speed * ringSquash;
              const initSpeedY = Math.cos(angle) * speed;
              // Rotate ring
              const newSpeed = MyMath.pointDist(0, 0, initSpeedX, initSpeedY);
              const newAngle = MyMath.pointAngle(0, 0, initSpeedX, initSpeedY) + ringStartAngle;
              const star = Star.add(
                x,
                y,
                color,
                newAngle,
                // apply near cubic falloff to speed (places more particles towards outside)
                newSpeed,//speed,
                // add minor variation to star life
                this.starLife + Math.random() * this.starLife * this.starLifeVariation
              );
              if (this.glitter) {
                star.sparkFreq = sparkFreq;
                star.sparkSpeed = sparkSpeed;
                star.sparkLife = sparkLife;
                star.sparkLifeVariation = sparkLifeVariation;
                star.sparkColor = this.glitterColor;
                star.sparkTimer = Math.random() * star.sparkFreq;
              }
            });
          }
          // "Normal burst
          else {
            createParticleArc(0, PI_2, this.starCount, 1, starFactory);
          }
        }
        else if (Array.isArray(this.color)) {
          let start, start2, arc;
          if (Math.random() < 0.5) {
            start = Math.random() * Math.PI;
            start2 = start + Math.PI;
            arc = Math.PI;
          } else {
            start = 0;
            start2 = 0;
            arc = PI_2;
          }
          color = this.color[0];
          createParticleArc(start, arc, this.starCount / 2, 1, starFactory);
          color = this.color[1];
          createParticleArc(start2, arc, this.starCount / 2, 1, starFactory)
        }
        if (this.pistil) {
          const innerShell = new Shell({
            size: this.size * 0.5,
            starLife: this.starLife * 0.7,
            starLifeVariation: this.starLifeVariation,
            starDensity: 1.65,
            color: this.pistilColor,
            glitter: 'light',
            glitterColor: this.pistilColor === COLOR.Gold ? COLOR.Gold : COLOR.White
          });
          innerShell.burst(x, y);
        }
        if (this.streamers) {
          const innerShell = new Shell({
            size: this.size,
            starLife: this.starLife * 0.8,
            starLifeVariation: this.starLifeVariation,
            starCount: Math.max(6, this.size / 45) | 0,
            color: COLOR.White,
            glitter: 'streamer'
          });
          innerShell.burst(x, y);
        }
        // Queue burst flash render
        BurstFlash.add(x, y, this.size / 8);
      }
    }



    const BurstFlash = {
      active: [],
      _pool: [],
      _new() {
        return {}
      },
      add(x, y, radius) {
        const instance = this._pool.pop() || this._new();
        instance.x = x;
        instance.y = y;
        instance.radius = radius;
        this.active.push(instance);
        return instance;
      },
      returnInstance(instance) {
        this._pool.push(instance);
      }
    };



    // Helper to generate objects for storing active particles.
    // Particles are stored in arrays keyed by color (code, not name) for improved rendering performance.
    function createParticleCollection() {
      const collection = {};
      COLOR_CODES_W_INVIS.forEach(color => {
        collection[color] = [];
      });
      return collection;
    }

    const Star = {
      // Visual properties
      drawWidth: 3,
      airDrag: 0.98,
      airDragHeavy: 0.992,
      // Star particles will be keyed by color
      active: createParticleCollection(),
      _pool: [],
      _new() {
        return {};
      },

      add(x, y, color, angle, speed, life, speedOffX, speedOffY) {
        const instance = this._pool.pop() || this._new();
        instance.heavy = false;
        instance.x = x;
        instance.y = y;
        instance.prevX = x;
        instance.prevY = y;
        instance.color = color;
        instance.speedX = Math.sin(angle) * speed + (speedOffX || 0);
        instance.speedY = Math.cos(angle) * speed + (speedOffY || 0);
        instance.life = life;
        instance.spinAngle = Math.random() * PI_2;
        instance.spinSpeed = 0.8;
        instance.spinRadius = 0;
        instance.sparkFreq = 0; // ms between spark emissions
        instance.sparkSpeed = 1;
        instance.sparkTimer = 0;
        instance.sparkColor = color;
        instance.sparkLife = 750;
        instance.sparkLifeVariation = 0.25;
        this.active[color].push(instance);
        return instance;
      },

      // Public method for cleaning up and returning an instance back to the pool.
      returnInstance(instance) {
        // Call onDeath handler if available (and pass it current star instance)
        instance.onDeath && instance.onDeath(instance);
        // Clean up
        instance.onDeath = null;
        // Add back to the pool.
        this._pool.push(instance);
      }
    };


    const Spark = {
      // Visual properties
      drawWidth: 0.75,
      airDrag: 0.9,
      // Star particles will be keyed by color
      active: createParticleCollection(),
      _pool: [],
      _new() {
        return {};
      },

      add(x, y, color, angle, speed, life) {
        const instance = this._pool.pop() || this._new();
        instance.x = x;
        instance.y = y;
        instance.prevX = x;
        instance.prevY = y;
        instance.color = color;
        instance.speedX = Math.sin(angle) * speed;
        instance.speedY = Math.cos(angle) * speed;
        instance.life = life;
        this.active[color].push(instance);
        return instance;
      },

      // Public method for cleaning up and returning an instance back to the pool.
      returnInstance(instance) {
        // Add back to the pool.
        this._pool.push(instance);
      }
    };

    function init() {
      // Populate dropdowns
      // shell type
      let options = '';
      shellNames.forEach(opt => options += options += '<option value="' + opt + '">' + opt + '</option>');
      appNodes.shellType.innerHTML = options;
      // shell size
      options = '';
      ['3"', '5"', '6"', '8"', '12"'].forEach((opt, i) => options += '<option value="' + opt + '">' + opt + '</option>');
      appNodes.shellSize.innerHTML = options;

      renderApp(store.state);
    }

  </script>

</body>

</html>